Išsami JavaScript WeakRef ir FinalizationRegistry analizė, skirta efektyviam atminties naudojimo stebėtojo šablonui. Išmokite išvengti atminties nutekėjimo didelėse sistemose.
JavaScript WeakRef stebėtojo šablonas: atmintį tausojančių įvykių sistemų kūrimas
Šiuolaikinio žiniatinklio kūrimo pasaulyje vieno puslapio aplikacijos (SPA) tapo standartu kuriant dinamiškas ir greitai reaguojančias vartotojo sąsajas. Šios aplikacijos dažnai veikia ilgą laiką, valdo sudėtingas būsenas ir apdoroja begalę vartotojo sąveikų. Tačiau ši ilgaamžiškumas turi paslėptą kainą: padidėjusią atminties nutekėjimo riziką. Atminties nutekėjimas, kai programa laiko atmintį, kurios jai nebereikia, laikui bėgant gali pabloginti našumą, sukelti lėtumą, naršyklės gedimus ir prastą vartotojo patirtį. Vienas iš dažniausių šių nutekėjimų šaltinių slypi esminiame projektavimo šablone: stebėtojo šablone (Observer pattern).
Stebėtojo šablonas yra įvykiais valdomos architektūros kertinis akmuo, leidžiantis objektams (stebėtojams) prenumeruoti ir gauti atnaujinimus iš centrinio objekto (subjekto). Jis elegantiškas, paprastas ir neįtikėtinai naudingas. Tačiau jo klasikinis įgyvendinimas turi kritinį trūkumą: subjektas palaiko stiprias nuorodas (strong references) į savo stebėtojus. Jei stebėtojo likusiai programos daliai nebereikia, bet programuotojas pamiršta jį aiškiai atregistruoti iš subjekto, jis niekada nebus surinktas šiukšlių rinkiklio (garbage collected). Jis lieka įstrigęs atmintyje – vaiduoklis, persekiojantis jūsų programos našumą.
Būtent čia šiuolaikinis JavaScript su savo ECMAScript 2021 (ES12) funkcijomis siūlo galingą sprendimą. Pasitelkdami WeakRef ir FinalizationRegistry, galime sukurti atmintį tausojantį stebėtojo šabloną, kuris automatiškai išsivalo, užkirsdamas kelią šiems dažniems nutekėjimams. Šis straipsnis – tai išsami šios pažangios technikos analizė. Mes išnagrinėsime problemą, suprasime įrankius, sukursime tvirtą įgyvendinimą nuo nulio ir aptarsime, kada ir kur šis galingas šablonas turėtų būti taikomas jūsų globaliose aplikacijose.
Pagrindinės problemos supratimas: klasikinis stebėtojo šablonas ir jo atminties pėdsakas
Prieš įvertindami sprendimą, turime visiškai suvokti problemą. Stebėtojo šablonas, dar žinomas kaip leidėjo-prenumeratoriaus (Publisher-Subscriber) šablonas, yra skirtas komponentų atsiejimui. Subjektas (arba leidėjas) saugo savo priklausomų objektų, vadinamų stebėtojais (arba prenumeratoriais), sąrašą. Kai subjekto būsena pasikeičia, jis automatiškai praneša visiems savo stebėtojams, paprastai iškviesdamas konkretų jų metodą, pavyzdžiui, update().
Panagrinėkime paprastą, klasikinį įgyvendinimą JavaScript kalboje.
Paprastas subjekto įgyvendinimas
Štai pagrindinė subjekto klasė. Ji turi metodus, skirtus stebėtojams prenumeruoti, atšaukti prenumeratą ir pranešti.
class ClassicSubject {
constructor() {
this.observers = [];
}
subscribe(observer) {
this.observers.push(observer);
console.log(`${observer.name} has subscribed.`);
}
unsubscribe(observer) {
this.observers = this.observers.filter(obs => obs !== observer);
console.log(`${observer.name} has unsubscribed.`);
}
notify(data) {
console.log('Notifying observers...');
this.observers.forEach(observer => observer.update(data));
}
}
Ir štai paprasta stebėtojo klasė, kuri gali prenumeruoti subjektą.
class Observer {
constructor(name) {
this.name = name;
}
update(data) {
console.log(`${this.name} received data: ${data}`);
}
}
Paslėptas pavojus: užsilikusios nuorodos
Šis įgyvendinimas veikia puikiai, kol kruopščiai valdome savo stebėtojų gyvavimo ciklą. Problema iškyla, kai to nedarome. Apsvarstykite dažną scenarijų didelėje programoje: ilgaamžė globali duomenų saugykla (subjektas) ir laikinas vartotojo sąsajos komponentas (stebėtojas), kuris rodo dalį tų duomenų.
Imituokime šį scenarijų:
const dataStore = new ClassicSubject();
function manageUIComponent() {
let chartComponent = new Observer('ChartComponent');
dataStore.subscribe(chartComponent);
// Komponentas atlieka savo darbą...
// Dabar vartotojas pereina kitur, ir komponento nebereikia.
// Programuotojas gali pamiršti pridėti išvalymo kodą:
// dataStore.unsubscribe(chartComponent);
chartComponent = null; // Mes atleidžiame savo nuorodą į komponentą.
}
manageUIComponent();
// Vėliau programos gyvavimo cikle...
dataStore.notify('New data available!');
`manageUIComponent` funkcijoje sukuriame `chartComponent` ir jį prenumeruojame prie mūsų `dataStore`. Vėliau `chartComponent` priskiriame `null`, signalizuodami, kad baigėme su juo dirbti. Tikimės, kad JavaScript šiukšlių rinkiklis (GC) pamatys, jog daugiau nėra nuorodų į šį objektą, ir atlaisvins jo atmintį.
Bet yra dar viena nuoroda! `dataStore.observers` masyvas vis dar laiko tiesioginę, stiprią nuorodą į `chartComponent` objektą. Dėl šios vienintelės užsilikusios nuorodos šiukšlių rinkiklis negali atlaisvinti atminties. `chartComponent` objektas ir visi jo turimi resursai liks atmintyje visą `dataStore` gyvavimo laiką. Jei tai kartojasi – pavyzdžiui, kiekvieną kartą, kai vartotojas atidaro ir uždaro modalinį langą – programos atminties naudojimas augs neribotai. Tai yra klasikinis atminties nutekėjimas.
Nauja viltis: pristatome WeakRef ir FinalizationRegistry
ECMAScript 2021 pristatė dvi naujas funkcijas, specialiai sukurtas spręsti tokius atminties valdymo iššūkius: `WeakRef` ir `FinalizationRegistry`. Tai pažangūs įrankiai, kuriuos reikia naudoti atsargiai, bet mūsų stebėtojo šablono problemai jie yra tobulas sprendimas.
Kas yra WeakRef?
`WeakRef` objektas laiko silpną nuorodą į kitą objektą, vadinamą jo taikiniu. Pagrindinis skirtumas tarp silpnos nuorodos ir įprastos (stiprios) nuorodos yra šis: silpna nuoroda netrukdo jos taikinio objektui būti surinktam šiukšlių rinkiklio.
Jei vienintelės nuorodos į objektą yra silpnos nuorodos, JavaScript variklis gali laisvai sunaikinti objektą ir atlaisvinti jo atmintį. Būtent to mums ir reikia, norint išspręsti stebėtojo problemą.
Norėdami naudoti `WeakRef`, sukuriate jo egzempliorių, perduodami taikinio objektą konstruktoriui. Norėdami vėliau pasiekti taikinio objektą, naudojate `deref()` metodą.
let targetObject = { id: 42 };
const weakRefToObject = new WeakRef(targetObject);
// Norėdami pasiekti objektą:
const retrievedObject = weakRefToObject.deref();
if (retrievedObject) {
console.log(`Object is still alive: ${retrievedObject.id}`); // Išvestis: Object is still alive: 42
} else {
console.log('Object has been garbage collected.');
}
Svarbiausia dalis yra ta, kad `deref()` gali grąžinti `undefined`. Taip atsitinka, jei `targetObject` buvo surinktas šiukšlių rinkiklio, nes į jį nebeliko stiprių nuorodų. Šis elgesys yra mūsų atmintį tausojančio stebėtojo šablono pagrindas.
Kas yra FinalizationRegistry?
Nors `WeakRef` leidžia objektą surinkti, jis nesuteikia mums aiškaus būdo sužinoti, kada jis buvo surinktas. Galėtume periodiškai tikrinti `deref()` ir šalinti `undefined` rezultatus iš mūsų stebėtojų sąrašo, bet tai neefektyvu. Būtent čia į pagalbą ateina `FinalizationRegistry`.
`FinalizationRegistry` leidžia užregistruoti atgalinio iškvietimo (callback) funkciją, kuri bus iškviesta po to, kai registruotas objektas bus surinktas šiukšlių rinkiklio. Tai yra post-mortem išvalymo mechanizmas.
Štai kaip tai veikia:
- Sukuriate registrą su išvalymo atgalinio iškvietimo funkcija.
- Užregistruojate (`register()`) objektą registre. Taip pat galite pateikti `heldValue` – duomenų dalį, kuri bus perduota jūsų atgalinio iškvietimo funkcijai, kai objektas bus surinktas. Šis `heldValue` negali būti tiesioginė nuoroda į patį objektą, nes tai prieštarautų tikslui!
// 1. Sukurkite registrą su išvalymo atgalinio iškvietimo funkcija
const registry = new FinalizationRegistry(heldValue => {
console.log(`An object has been garbage collected. Cleanup token: ${heldValue}`);
});
(function() {
let objectToTrack = { name: 'Temporary Data' };
let cleanupToken = 'temp-data-123';
// 2. Užregistruokite objektą ir pateikite žetoną išvalymui
registry.register(objectToTrack, cleanupToken);
// objectToTrack išeina iš apibrėžties srities
})();
// Kažkuriuo metu ateityje, po GC paleidimo, konsolėje bus išvesta:
// "An object has been garbage collected. Cleanup token: temp-data-123"
Svarbūs įspėjimai ir gerosios praktikos
Prieš pradedant įgyvendinimą, labai svarbu suprasti šių įrankių prigimtį. Šiukšlių rinkiklio elgesys labai priklauso nuo įgyvendinimo ir yra nedeterministinis. Tai reiškia:
- Jūs negalite numatyti, kada objektas bus surinktas. Tai gali įvykti po kelių sekundžių, minučių ar net ilgiau, kai jis taps nepasiekiamas.
- Jūs negalite pasikliauti, kad `FinalizationRegistry` atgalinio iškvietimo funkcijos bus įvykdytos laiku ar nuspėjamai. Jos skirtos išvalymui, o ne kritinei programos logikai.
- Pernelyg dažnas `WeakRef` ir `FinalizationRegistry` naudojimas gali apsunkinti kodo supratimą. Visada teikite pirmenybę paprastesniems sprendimams (pvz., aiškiems `unsubscribe` iškvietimams), jei objektų gyvavimo ciklai yra aiškūs ir valdomi.
Šios funkcijos geriausiai tinka situacijose, kai vieno objekto (stebėtojo) gyvavimo ciklas yra tikrai nepriklausomas nuo kito objekto (subjekto) ir jam nežinomas.
`WeakRefObserver` šablono kūrimas: žingsnis po žingsnio įgyvendinimas
Dabar sujunkime `WeakRef` ir `FinalizationRegistry` ir sukurkime atminties požiūriu saugią `WeakRefSubject` klasę.
1 žingsnis: `WeakRefSubject` klasės struktūra
Mūsų nauja klasė saugos `WeakRef` nuorodas į stebėtojus, o ne tiesiogines nuorodas. Ji taip pat turės `FinalizationRegistry`, skirtą automatiniam stebėtojų sąrašo išvalymui.
class WeakRefSubject {
constructor() {
this.observers = new Set(); // Naudojame Set, kad būtų lengviau pašalinti
// Finalizatoriaus atgalinio iškvietimo funkcija. Ji gauna mūsų pateiktą "held value" registracijos metu.
// Mūsų atveju "held value" bus pati WeakRef instancija.
this.cleanupRegistry = new FinalizationRegistry(weakRefObserver => {
console.log('Finalizer: An observer has been garbage collected. Cleaning up...');
this.observers.delete(weakRefObserver);
});
}
}
Stebėtojų sąrašui naudojame `Set`, o ne `Array`. Taip yra todėl, kad elemento pašalinimas iš `Set` yra daug efektyvesnis (O(1) vidutinis laiko sudėtingumas) nei masyvo filtravimas (O(n)), o tai bus naudinga mūsų išvalymo logikoje.
2 žingsnis: `subscribe` metodas
`subscribe` metode prasideda magija. Kai stebėtojas užsiprenumeruoja, mes:
- Sukursime `WeakRef`, kuris rodo į stebėtoją.
- Pridėsime šį `WeakRef` į mūsų `observers` rinkinį.
- Užregistruosime originalų stebėtojo objektą mūsų `FinalizationRegistry`, naudodami naujai sukurtą `WeakRef` kaip `heldValue`.
// WeakRefSubject klasės viduje...
subscribe(observer) {
// Patikrinkite, ar stebėtojas su šia nuoroda jau egzistuoja
for (const ref of this.observers) {
if (ref.deref() === observer) {
console.warn('Observer already subscribed.');
return;
}
}
const weakRefObserver = new WeakRef(observer);
this.observers.add(weakRefObserver);
// Užregistruokite originalų stebėtojo objektą. Kai jis bus surinktas,
// finalizatorius bus iškviestas su `weakRefObserver` kaip argumentu.
this.cleanupRegistry.register(observer, weakRefObserver);
console.log('An observer has subscribed.');
}
Ši sąranka sukuria protingą ciklą: subjektas laiko silpną nuorodą į stebėtoją. Registras (viduje) laiko stiprią nuorodą į stebėtoją, kol jis bus surinktas šiukšlių rinkiklio. Surinkus, registro atgalinio iškvietimo funkcija suaktyvinama su silpnos nuorodos instancija, kurią galime naudoti išvalydami savo `observers` rinkinį.
3 žingsnis: `unsubscribe` metodas
Net ir su automatiniu išvalymu, turėtume pateikti rankinį `unsubscribe` metodą atvejams, kai reikalingas deterministinis pašalinimas. Šis metodas turės surasti teisingą `WeakRef` mūsų rinkinyje, išgaudamas kiekvieną iš jų ir palygindamas su stebėtoju, kurį norime pašalinti.
// WeakRefSubject klasės viduje...
unsubscribe(observer) {
let refToRemove = null;
for (const weakRef of this.observers) {
if (weakRef.deref() === observer) {
refToRemove = weakRef;
break;
}
}
if (refToRemove) {
this.observers.delete(refToRemove);
// SVARBU: Mes taip pat turime išregistruoti iš finalizatoriaus,
// kad išvengtume nereikalingo atgalinio iškvietimo vėliau.
this.cleanupRegistry.unregister(observer);
console.log('An observer has unsubscribed manually.');
}
}
4 žingsnis: `notify` metodas
`notify` metodas iteruoja per mūsų `WeakRef` rinkinį. Kiekvienam iš jų bandoma iškviesti `deref()`, kad gautume tikrąjį stebėtojo objektą. Jei `deref()` pavyksta, tai reiškia, kad stebėtojas vis dar gyvas, ir mes galime iškviesti jo `update` metodą. Jei jis grąžina `undefined`, stebėtojas buvo surinktas, ir mes galime jį tiesiog ignoruoti. `FinalizationRegistry` galiausiai pašalins jo `WeakRef` iš rinkinio.
// WeakRefSubject klasės viduje...
notify(data) {
console.log('Notifying observers...');
for (const weakRefObserver of this.observers) {
const observer = weakRefObserver.deref();
if (observer) {
// Stebėtojas vis dar gyvas
observer.update(data);
} else {
// Stebėtojas buvo surinktas šiukšlių rinkiklio.
// FinalizationRegistry pasirūpins šio weakRef pašalinimu iš rinkinio.
console.log('Found a dead observer reference during notification.');
}
}
}
Viską sujunkime: praktinis pavyzdys
Grįžkime prie mūsų UI komponento scenarijaus, bet šį kartą naudodami naująjį `WeakRefSubject`. Paprastumo dėlei naudosime tą pačią `Observer` klasę kaip ir anksčiau.
// Ta pati paprasta Observer klasė
class Observer {
constructor(name) {
this.name = name;
}
update(data) {
console.log(`${this.name} received data: ${data}`);
}
}
Dabar sukurkime globalią duomenų paslaugą ir imituokime laikiną UI valdiklį (widget).
const globalDataService = new WeakRefSubject();
function createAndDestroyWidget() {
console.log('--- Creating and subscribing new widget ---');
let chartWidget = new Observer('RealTimeChartWidget');
globalDataService.subscribe(chartWidget);
// Valdiklis dabar aktyvus ir gaus pranešimus
globalDataService.notify({ price: 100 });
console.log('--- Destroying widget (releasing our reference) ---');
// Baigėme darbą su valdikliu. Nustatome savo nuorodą į null.
// Mums NEREIKIA kviesti unsubscribe().
chartWidget = null;
}
createAndDestroyWidget();
console.log('--- After widget destruction, before garbage collection ---');
globalDataService.notify({ price: 105 });
Įvykdžius `createAndDestroyWidget()`, `chartWidget` objektą dabar referuoja tik `WeakRef` mūsų `globalDataService` viduje. Kadangi tai yra silpna nuoroda, objektas dabar gali būti surinktas šiukšlių rinkiklio.
Kai šiukšlių rinkiklis galiausiai pasileis (ko negalime nuspėti), įvyks du dalykai:
- `chartWidget` objektas bus pašalintas iš atminties.
- Mūsų `FinalizationRegistry` atgalinio iškvietimo funkcija bus suaktyvinta, kuri pašalins dabar jau nebegyvą `WeakRef` iš `globalDataService.observers` rinkinio.
Jei po šiukšlių rinkiklio paleidimo vėl iškviesime `notify`, `deref()` iškvietimas grąžins `undefined`, negyvas stebėtojas bus praleistas, o programa toliau veiks efektyviai be jokių atminties nutekėjimų. Mes sėkmingai atsiejome stebėtojo gyvavimo ciklą nuo subjekto.
Kada naudoti (ir kada vengti) `WeakRefObserver` šabloną
Šis šablonas yra galingas, bet tai ne panacėja. Jis įveda sudėtingumo ir remiasi nedeterministiniu elgesiu. Labai svarbu žinoti, kada tai yra tinkamas įrankis.
Idealūs naudojimo atvejai
- Ilgaamžiai subjektai ir trumpaamžiai stebėtojai: Tai kanoninis naudojimo atvejis. Globali paslauga, duomenų saugykla ar podėlis (subjektas), egzistuojantis visą programos gyvavimo ciklą, o daugybė UI komponentų, laikinų darbininkų ar įskiepių (stebėtojų) yra dažnai kuriami ir naikinami.
- Podėlio (caching) mechanizmai: Įsivaizduokite podėlį, kuris susieja sudėtingą objektą su tam tikru apskaičiuotu rezultatu. Galite naudoti `WeakRef` rakto objektui. Jei originalus objektas yra surinktas šiukšlių rinkiklio iš likusios programos dalies, `FinalizationRegistry` gali automatiškai išvalyti atitinkamą įrašą jūsų podėlyje, užkertant kelią atminties išsipūtimui.
- Įskiepių ir plėtinių architektūros: Jei kuriate pagrindinę sistemą, leidžiančią trečiųjų šalių moduliams prenumeruoti įvykius, `WeakRefObserver` naudojimas prideda atsparumo sluoksnį. Tai apsaugo nuo prastai parašyto įskiepio, kuris pamiršta atšaukti prenumeratą, sukeliančio atminties nutekėjimą jūsų pagrindinėje programoje.
- Duomenų susiejimas su DOM elementais: Scenarijuose be deklaratyvios sistemos (framework) galite norėti susieti tam tikrus duomenis su DOM elementu. Jei tai saugote žemėlapyje (map) su DOM elementu kaip raktu, galite sukelti atminties nutekėjimą, jei elementas pašalinamas iš DOM, bet vis dar yra jūsų žemėlapyje. Čia geresnis pasirinkimas yra `WeakMap`, tačiau principas tas pats: duomenų gyvavimo ciklas turėtų būti susietas su elemento gyvavimo ciklu, o ne atvirkščiai.
Kada likti prie klasikinio stebėtojo
- Glaudžiai susieti gyvavimo ciklai: Jei subjektas ir jo stebėtojai visada kuriami ir naikinami kartu arba toje pačioje apibrėžties srityje, `WeakRef` sudėtingumas ir pridėtinės išlaidos yra nereikalingos. Paprastas, aiškus `unsubscribe()` iškvietimas yra skaitomesnis ir nuspėjamesnis.
- Našumui kritiški „karštieji takai“ (hot paths): `deref()` metodas turi nedidelę, bet ne nulinę našumo kainą. Jei pranešate tūkstančiams stebėtojų šimtus kartų per sekundę (pvz., žaidimo cikle ar aukšto dažnio duomenų vizualizacijoje), klasikinis įgyvendinimas su tiesioginėmis nuorodomis bus greitesnis.
- Paprastos programos ir scenarijai: Mažesnėms programoms ar scenarijams, kur programos gyvavimo laikas yra trumpas ir atminties valdymas nėra didelė problema, klasikinis šablonas yra paprastesnis įgyvendinti ir suprasti. Nepridėkite sudėtingumo ten, kur jo nereikia.
- Kai reikalingas deterministinis išvalymas: Jei jums reikia atlikti veiksmą tiksliai tuo momentu, kai stebėtojas yra atjungiamas (pvz., atnaujinti skaitiklį, atlaisvinti konkretų aparatinės įrangos resursą), jūs privalote naudoti rankinį `unsubscribe()` metodą. Dėl nedeterministinės `FinalizationRegistry` prigimties jis netinka logikai, kuri turi būti vykdoma nuspėjamai.
Platesnės pasekmės programinės įrangos architektūrai
Silpnų nuorodų įvedimas į aukšto lygio kalbą, tokią kaip JavaScript, signalizuoja platformos brandą. Tai leidžia programuotojams kurti sudėtingesnes ir atsparesnes sistemas, ypač ilgai veikiančioms programoms. Šis šablonas skatina pokytį architektūriniame mąstyme:
- Tikras atsiejimas (Decoupling): Tai leidžia pasiekti atsiejimo lygį, kuris peržengia tik sąsajos ribas. Dabar galime atsieti pačius komponentų gyvavimo ciklus. Subjektui nebereikia nieko žinoti apie tai, kada jo stebėtojai yra kuriami ar naikinami.
- Atsparumas pagal dizainą: Tai padeda kurti sistemas, kurios yra atsparesnės programuotojo klaidoms. Pamirštas `unsubscribe()` iškvietimas yra dažna klaida, kurią gali būti sunku susekti. Šis šablonas sumažina visą šią klaidų klasę.
- Pagalba sistemų ir bibliotekų autoriams: Tiems, kas kuria sistemas, bibliotekas ar platformas kitiems programuotojams, šie įrankiai yra neįkainojami. Jie leidžia kurti tvirtas API, kurios yra mažiau jautrios netinkamam bibliotekos vartotojų naudojimui, o tai lemia stabilesnes programas apskritai.
Išvada: galingas įrankis šiuolaikiniam JavaScript programuotojui
Klasikinis stebėtojo šablonas yra fundamentalus programinės įrangos projektavimo elementas, tačiau jo priklausomybė nuo stiprių nuorodų ilgą laiką buvo subtilių ir varginančių atminties nutekėjimų šaltinis JavaScript programose. Su `WeakRef` ir `FinalizationRegistry` atėjimu ES2021, dabar turime įrankius šiam apribojimui įveikti.
Mes nukeliavome nuo pagrindinės užsilikusių nuorodų problemos supratimo iki pilno, atmintį tausojančio `WeakRefSubject` sukūrimo nuo nulio. Matėme, kaip `WeakRef` leidžia objektams būti surinktiems šiukšlių rinkiklio net kai jie yra „stebimi“, ir kaip `FinalizationRegistry` suteikia automatinį išvalymo mechanizmą, kad mūsų stebėtojų sąrašas liktų nepriekaištingas.
Tačiau su didele galia ateina ir didelė atsakomybė. Tai pažangios funkcijos, kurių nedeterministinė prigimtis reikalauja kruopštaus apsvarstymo. Jos nepakeičia gero programos dizaino ir kruopštaus gyvavimo ciklo valdymo. Bet kai taikomos tinkamoms problemoms – pavyzdžiui, valdant komunikaciją tarp ilgaamžių paslaugų ir laikinų komponentų – WeakRef stebėtojo šablonas yra išskirtinai galinga technika. Jį įvaldę, galėsite rašyti tvirtesnes, efektyvesnes ir labiau keičiamo dydžio JavaScript programas, pasirengusias atitikti šiuolaikinio, dinamiško žiniatinklio reikalavimus.